在 Borrow Checker 之前,Rust 先做了一场“系统级 Erlang”的梦

如果把今天的 Rust 拆成几个关键词,大概会得到:所有权、借用、生命周期、零成本抽象、无垃圾回收。

然后你打开 rust-prehistory,会发现早期 Rust 一度拥有垃圾回收器、对象、动态类型、first-class module、运行时热替换设想、语言内建的轻量任务,以及一套野心大得吓人的 typestate 证明系统。

更离谱的是,那个后来以“无畏并发”闻名的 Rust,最初并不是从 borrow checker 出发去解决并发。恰好相反:它先认定并发和故障隔离是语言的中心任务,然后在好几年里反复试验,才逐渐逼近我们今天熟悉的所有权。

@Graydon Hoare 在仓库说明里提醒读者,不要把这些提交看成一份宏伟计划的执行记录。他说,那更像是“妄想中的梦逐渐聚焦”,期间有长时间的停顿、自我争辩、错误起步,以及一些今天看来荒唐的岔路。这个提示很重要。后见之明总喜欢给成功补一条笔直的因果线,而这个仓库保存的,偏偏是那条线还没有被画出来时的样子。

第一幕:一个解释器,因为“不够并发”而被判了死刑

2006 年 7 月 23 日,Rust 的第一次提交落地。最初的手册已经把它描述成一门混合 imperative-procedural、concurrent-actor 和 pure-functional 风格的语言。那时还没有 Mozilla,没有 Rust 社区,也没有螃蟹,只有 Graydon 和一个几乎没人知道能否运行起来的个人项目。

两个月后,项目刚让树解释器勉强走过语句块,Graydon 就在一条提交信息里突然醒悟:

duh, of course we can't use a tree interpreter for a concurrent language.

“对哦,一门并发语言当然不能用树解释器。”于是他开始写栈机代码生成器。这条提交最有趣的不是技术判断是否完全正确,而是语气:并发不是后加的功能,它已经强到足以决定编译器该长什么样。

随后出现的是 process、program、port、channel 和 multiprocess scheduler。2007 年 9 月,一条提交兴奋地写着 “yay, multiprocessing works”。到 2010 年的手册里,Rust 程序被定义成一棵轻量 task 树;task 彼此不能直接修改内存,只能通过 typed、asynchronous、simplex 的消息系统通信。port 负责接收,channel 负责发送。你甚至可以写:

let port[int] po = port();
let chan[int] ch = chan(po);
spawn worker(ch);
let int value <- po;

这更像 Erlang、Newsqueak、Alef 或早期 Go 的亲戚,而不是今天那个把线程、锁和原子类型交给库的 Rust。早期手册也毫不掩饰这条血统:Erlang、NIL、Hermes、Sather 和 Newsqueak 被列为主要影响来源;“fault isolation”与“没有共享可变状态”出现在语言推销页最醒目的位置。

这里还有一个很反直觉的细节。最初的 channel 是无缓冲的,发送者可以直接写进接收者提供的槽位,看起来又快又省。但设计笔记很快发现,无缓冲会让双方在时间上绑定得过紧:如果发送者和接收者都只想轮询、不愿阻塞,双方就可能永远无法表达“我准备好了”。因此运行时后来改成 channel 永远至少带一个缓冲位

今天 Rust 标准库里的 sync_channel(0) 恰好允许 rendezvous channel,异步生态也提供各种背压策略。早期结论没有成为永恒真理,但那段推理留下了 Rust 一贯的气质:并发语义不能靠“实现应该足够聪明”来糊弄,阻塞、内存预算和失败方式都得说清楚。

第二幕:所有权的祖先,曾经几次被亲手杀死

现在谈 Rust,所有权像语言的创世神话。可在史前仓库里,它更像一名不断换演员的角色。

最早的内存模型把值组织成一张 DAG。值可以在 slot 之间 move,也可以 copy;共享部分使用 copy-on-write 和引用计数。某些值被标记为 limited,不能复制,也不能通过 channel 传输。这里已经能看到现代 Rust 的影子:资源不能随便复制,移动会改变源位置的状态,编译器追踪 slot 是否初始化。

但设计并没有沿着这条路稳定前进。2008 年 6 月的笔记先宣布:

The “move” operator is dead.

理由是所有类型都可以复制引用,真正需要限制的只是能否跨 channel 传输。2009 年 10 月,limited type 被删除。一个月后,copy-on-write 也被删除,提交信息最后带了一句近乎投降的 “Forgive me”。可变内存于是可以形成环,编译器加入 mutability 分析和 GC 分析,运行时为每个 task 的本地 heap 准备 cycle-aware garbage collector;跨 task 共享的不可变对象则继续使用引用计数。

换句话说,2009 年末的 Rust 不但有 GC,而且把“本地可变对象由 GC 管、跨任务不可变对象由 RC 管”写进了内存模型。这并非今天 Rust 的一个粗糙早期版本,而是一条后来被放弃的完整路线。

与此同时,另一组概念悄悄留下来。slot 必须先初始化才能读取;函数参数可以是只读 alias 或 mutable alias;可变对象不能跨 task 共享;退出作用域时,编译器插入 drop glue,释放引用和资源。这些零件当时服务于 CoW、RC、GC、alias mode 等多套机制,后来经过更大的重写,才被重新组合成 ownership、borrowing、&T&mut T 与析构。

所以,现代 Rust 的所有权并不是一开始就被发现的宝石。更像是设计者先后试过 DAG、显式 move/copy、limited type、CoW、局部 GC 和引用计数之后,逐渐认清了一件事:若想同时保住可预测的资源管理、内存安全和共享可变状态的边界,语言必须对“谁能访问什么”管得比原先更彻底。

第三幕:Borrow checker 之前,站着一个更野心勃勃的 Typestate

早期 Rust 最容易被今天的读者误认成 borrow checker 的东西,其实是 typestate。但二者不是一回事。

Typestate 为程序里的每个控制流位置维护一组逻辑谓词。一个 slot 是否初始化只是最简单的谓词;程序还可以声明纯布尔函数作为 predicate,在函数签名上写前置条件,用 check 在运行时验证条件,再让编译器把验证结果传播到后续代码;也可以用 prove 要求编译器静态证明某个条件成立。

手册给出的例子大致是:

fn even(&int x) -> bool {
    ret x & 1 == 0;
}

fn print_even(int x) : even(x) {
    print(x);
}

check even(y);
print_even(y); // check 之后,调用才合法

它甚至支持 constrained type,例如一个 ordered_range 可以附带 low < high 的约束。今天我们可能会把这类想法叫 refinement type、contract 或 flow-sensitive analysis;当年的 Rust 想把它直接塞进日常语言。

这套系统不是纸上谈兵。2009 年 3 月开始实现,4 月 24 日的提交宣布 typestate algorithm 已经工作。两天后,改进又“弄坏了所有测试”;再过两天,测试才全部通过。到仓库史前阶段的最后一天,它还在重写 constraint-key system。

后来,通用 typestate 从 Rust 中消失了。今天的编译器不会因为你刚刚 assert!(x % 2 == 0),就允许调用一个要求偶数参数的普通函数。但它的部分使命分散到了别处:确定性初始化仍由编译器检查;借用检查是控制流敏感的数据流分析;OptionResult 把可能失败的状态放进类型;库作者可以用 newtype 或 typestate pattern 编码状态机。

早期 Rust 想让编译器理解任意程序谓词,今天的 Rust 更克制:让编译器牢牢守住少数关键不变量,其余证明交给类型建模和库。野心缩小了,可信边界反而更清晰。

第四幕:Crate 原本想当一个会热替换的活物

crate 是少数从史前 Rust 活到今天、名字几乎没变的概念,但它早年的性格完全不同。

2006 年的设计把编译单元叫 container,目标产物是 ELF 或 PE 动态库。随后 crate 登场。早期运行时设想每个 crate 都带一张 table of contents,跨 crate 调用通过表间接跳转;某个模块重载后,依赖它的 crate 可以重新链接。设计笔记甚至讨论把 relink 信息保留在二进制里,以便热替换代码。

更奇特的是,Rust 自己生成并解析 DWARF。DWARF 通常是调试信息格式,早期 Rust 却拿它序列化 crate 级类型信息,再从目标文件中读回来,完成跨 crate 链接和 module type 重建。2010 年 2 月,运行时 crate loading 已能在所有平台工作。同一时期,first-class module 和 stateful module 也短暂跑通:模块可以像值一样传递,带抽象类型,甚至携带状态。

然后,2010 年 4 月 1 日,一条毫无留恋的提交删除 module type 和 first-class module,改画简单 object。两周后,stateful module 及 existential type 的残余也被清理。今天的 crate 是明确、静态、适合增量编译和生态分发的边界,不是一个运行时可重连的活模块。Rust 仍有 crate metadata,但早已不靠 DWARF 承担这项工作。

这次转向很能说明 Rust 后来的取舍:凡是让运行时和类型系统同时变得过于聪明的机制,哪怕已经“能跑”,也可能被删掉。实现成功不等于设计值得保留。

第五幕:“绝不需要元组。”——“等等,我们需要元组。”

如果要从仓库里挑一份最像人类思考现场的文件,我会选 type-decisions.txt

2007 年 9 月 9 日,Graydon 用全大写写下:

NO DAMN TUPLES

元组没有字段名,长到两三个元素就容易伤人;record 已经能完成异构积类型的工作,何必再加一套语法?论证相当完整。

几段之后,标题变成:

NO WAIT, WE NEED TUPLES

函数参数的名字不应该影响函数类型兼容性。如果参数用 nominal record 表示,那么 (int, int) -> int 的两个函数可能仅仅因为参数名不同而不兼容。元组又被请了回来。接着设计者继续纠结一元元组、参数传递模式、逗号到底算不算构造器,直到得出一条同样全大写的新决定:Rust 有元组,但它叫 tup[A,B,C]

这还不是结局。2008 年 tuple constructor , 回归;2010 年又为了消除一元素元组与括号表达式的歧义,改成 tup(a,b)。今天我们写 (a, b),一元元组靠 (a,) 区分。问题没有消失,只是得到了一套更能忍受的答案。

类似的“消失又变形”随处可见:

这些设计看起来像一间堆满原型的工作室。你能认出成品的轮廓,却很难说哪一件原型“进化成”了今天的功能。有些是直接祖先,有些只是解决了相同问题,还有些纯粹被扫进了垃圾桶。

第六幕:Unsafe 曾被设想成一种可隔离、可授权的“效果”

今天的 unsafe 是 Rust 最容易被误解的词:它不会关闭类型系统,只允许程序员执行几类编译器无法验证的操作,并把证明义务转交给人。

史前 Rust 对这个边界的处理更像 effect system。函数可以被标注为 pure、state、io 或 unsafe;调用关系会传播 effect。crate 还能用 auth 指令授权某个依赖,把它的 unsafe 部分视为可接受。最激进的设想是 spawn native:把 unsafe program 放到一个操作系统子进程中执行,再用进程隔离把它重新包装成“安全”的 task。

这几乎把 capability、effect tracking、FFI trust 和 OS sandbox 糅成了一门语言特性。2010 年 6 月,effect checker 真的开始实现:语法被替换,effect 在调用者和被调用者之间传播,auth 参与 masking。它并非一页漂亮的愿望清单。

今天 Rust 留下了一个更小、更锋利的核心:unsafe fnunsafe block、可审计的 unsafe operation,以及库 API 建立的安全抽象。SendSync 则把跨线程能力表达成 trait。语言没有替你启动隔离进程,也不试图用一个总效果格覆盖 I/O、状态和本地代码。

又一次,Rust 没有保留最宏大的机制,却保留了问题意识:安全不是一句全局承诺,而是由边界、授权和局部证明拼起来的。

最后:Rust 的传统不是“从不犯错”,而是舍得推翻自己

rust-prehistory 最动人的部分,不是我们能在 2006 年的文档里找到多少现代 Rust 的预言。硬要找的话,总能找到:没有 null、显式 sum type、初始化检查、移动语义、alias、不可共享的可变状态、轻量并发、故障隔离……把它们圈出来,很容易写成一篇“天才早已看见一切”的神话。

可仓库真正展示的是相反的事。

树解释器写了又扔;move 被宣布死亡,后来却成为语言门面;tuple 被驱逐又召回;limited type 被删除;CoW 被删时附上一句“原谅我”;first-class module 跑通后照样砍掉;GC、object、typestate、effect system 都曾经拥有足够具体的代码和手册,然后退出历史。

中间还有大量并不浪漫的劳动。Graydon 从逐字节编码 32 位 x86 指令开始,自己写 ELF、PE、Mach-O、DWARF emitter 和 parser、寄存器分配器、运行时调度器。2009 年 7 月,旧编译器被重命名为 rustboot,项目开始尝试 self-hosting;2010 年 3 月底 LLVM backend 才出现。所谓“语言设计”,在这里经常意味着研究 Windows 为什么要求 BSS 非零,或者修一个让 Valgrind 抱怨的 TLS 尾链。

这也解释了为什么今天的 Rust 同时显得理想主义又务实。它仍然固执地追求系统级安全、并发与性能,但不再试图把所有正确性问题一次性解决。它更愿意把少数规则打磨到可实现、可诊断、可供库组合,再把其余野心留给生态或未来。

Graydon 说,2009 年中后期工作量的上升,是因为 Mozilla 开始付钱让他正式开发。但在那之前,已经有几年“绕着圈子摆弄”的时间。那些圈子没有白绕。Rust 后来最坚硬的部分,未必来自最早的正确答案,反而来自那些被实现过、撞过墙、再被删掉的答案。

如果今天的 Rust 像一座结构严谨的桥,rust-prehistory 不是它的施工进度表。它是桥下那片堆满废弃桁架、断裂模型和手写公式的河滩。

成品告诉你什么成立。废墟才告诉你,成立有多难。


材料索引